Terraformにおけるディレクトリ構造のベストプラクティス
はじめに
こんにちは、中山です。
Terraformを使用していく中で、どのようなディレクトリ構造(tfファイルの配置方式)がベストなのかと考えたことはありませんか。私自身いろいろと試している最中なのですが、現時点で私が考えるベストプラクティスをご紹介します。
ディレクトリ構造
いきなりですが、以下のとおりです。
├── Makefile ├── README.md ├── app.tf ├── bastion.tf ├── cloudfront.tf ├── db.tf ├── elasticache.tf ├── elb.tf ├── envs │ ├── dev │ │ ├── main.tf │ │ └── variables.tf │ ├── prd │ │ ├── main.tf │ │ └── variables.tf │ └── stg │ ├── main.tf │ └── variables.tf ├── key_pair.tf ├── keys │ ├── site_key │ └── site_key.pub ├── modules │ ├── dns │ │ ├── dns.tf │ │ └── variables.tf │ └── iam │ ├── iam.tf │ ├── outputs.tf │ ├── policies │ │ └── ec2_assume_role_policy.json │ └── variables.tf ├── network.tf ├── outputs.tf ├── s3.tf ├── security_group.tf ├── sns.tf ├── user_data │ ├── app_cloud_config.yml │ └── bastion_cloud_config.yml └── variables.tf
なぜこの構造がよいのか
1. コードの見通しがよい
Terraformはデフォルトの動作として、カレントディレクトリ上の .tf
という拡張子が付いているファイルを全て実行対象として認識します。つまり、同じ内容であれば1つのtfファイルでも、それを分割したファイルであっても実行される内容は同じになります。全てのリソースを1つのファイル中に定義してしまうと、ファイルが巨大化し、コードの見通しが悪くなってしまいます。そのため、リソース毎あるいはネットワーク(VPC/サブネット/etc)などのコンポーネント単位でファイルを分割する構成にしています。
変数と出力用のファイルは別途専用のファイル( variables.tf
と outputs.tf
)を用意しています。また、キーペア用の秘密鍵や、ユーザデータなどのtfファイル以外のものに関しては、ディレクトリを1つ掘り、そこにまとめています。tfファイルとそれ以外のファイルが同じ階層に置いてあると ls
するときに見通しが悪くなるからです。
2. 環境毎にtfstateを分離できる
2017年4月2日追記
Terraform v0.9.0でEnvironmentという仕組みが導入されました。自分で環境を分ける仕組みを使うのではなく、こちらの機能を使った方がよいと思われます。
利用するリソースは基本的にほぼ同じだが環境毎に微妙に変更したい場合はあると思います。例えば、本番環境ではRDSのMulti-AZを利用するが、ステージング/開発環境ではコスト節約のためシングル構成にしたい、といった時です。こういった状況で、tfファイル自体を環境毎に別で管理してしまうと、例えばステージング/開発環境で適用した変更内容をまた別の本番環境用ファイルにも適用しなければならず、煩雑になりがちです。そのため、トップディレクトリに envs
というディレクトリを1つ掘っておき、そこに環境毎のファイルを設置する方式にしています。抜粋します。
envs/ ├── dev │ ├── main.tf │ └── variables.tf ├── prd │ ├── main.tf │ └── variables.tf └── stg ├── main.tf └── variables.tf
variables.tf
に環境毎に異なる内容を定義しておきます。例えば以下のような感じです。
variable "name" { default = "prd-hoge" } variable "region" { default = "ap-northeast-1" } variable "vpc_cidr" { default = "172.16.0.0/16" } variable "instance_types" { default = { "bastion" = "t2.nano" "app" = "t2.nano" } } <snip>
Terraformにはモジュールという機能があります。簡単に説明すると、別ディレクトリ/リモートにあるtfファイルを関数のように呼び出せる機能です。この機能を利用して main.tf
からトップディレクトリにあるtfファイルを呼び出しています。こうすることで、環境毎の差異は variables.tf
で吸収しつつ、tfファイルは共通で利用できるようにしています。 main.tf
の内容はプロバイダーの定義と、モジュールの呼び出しをしているのみです。
provider "aws" { region = "${var.region}" } module "prd" { source = "../../" name = "${var.name}" vpc_cidr = "${var.vpc_cidr}" email_address = "${var.email_address}" instance_types = "${var.instance_types}" asg_config = "${var.asg_config}" db_config = "${var.db_config}" cf_config = "${var.cf_config}" elasticache_config = "${var.elasticache_config}" azs = "${data.aws_availability_zones.az.names}" amazon_linux_id = "${data.aws_ami.amazon_linux.id}" domain_config = "${var.domain_config}" }
モジュールを利用する際の注意点として呼び出される側、今回の例ではトップディレクトリ中のtfファイル、でも変数の定義をする必要があります。変数の定義はトップディレクトリの variables.tf
で実施しています。
variable "name" {} variable "region" {} variable "vpc_cidr" {} variable "instance_types" { type = "map" } <snip>
type = "map"
という内容に注目していただきたいのですが、モジュールに渡す変数のタイプにリストとマップを指定可能です。これはv0.7.0から取り込まれた機能です。以前のバージョンでは文字列しか渡せなかったため、文字列変数を全て定義するか(モジュールの呼び出し元/先の両方で)、カンマ区切りの文字列を作っておき split
と element
関数を利用して配列的に扱う、といった涙ぐましい苦行が行われていました。詳細については以前私が書いた以下のエントリを御覧ください。
この構成における注意点として、Terraformの実行は環境毎に用意したディレクトリ上で実施する必要があります。いちいち移動するのも面倒なので、適当なシェルスクリプトなりmake系ファイルを用意しておきましょう。Makefileだったら例えば以下のような感じです。
BUCKET_NAME = hoge REGION = ap-northeast-1 CD = [[ -d envs/${ENV} ]] && cd envs/${ENV} ENV = $1 ARGS = $2 terraform: @${CD} && \ terraform ${ARGS} remote-enable: @${CD} && \ terraform remote config \ -backend=s3 \ -backend-config='bucket=${BUCKET_NAME}' \ -backend-config='key=${ENV}/terraform.tfstate' \ -backend-config='region=${REGION}' remote-disable: @${CD} && \ terraform remote config \ -disable
このようなファイルを用意することで、例えば apply
したい場合は以下のようにコマンドを叩くだけになります。
$ make terraform ENV=prd ARGS=apply
追記
もし複数のリージョンを利用する場合はマルチプルプロバイダ機能が使えます。以前私が書いたエントリもよかったら読んでみてください。また、そもそも環境を分ける必要がなければmain.tfをトップディレクトリに置いておけばOKです。内容はプロバイダの記述のみで、モジュールの設定すら必要ありません。
3. 特定の環境のみで定義したいリソースにも対応できる
2016年12月15日追記
Terraform v0.8.0のリリースにより限定的な条件文がサポートされました。この機能によりわざわざモジュールを利用せず条件分岐が可能になり、より可読性の高いコードの記述ができるようになりました。詳細についてはTerraform v0.8.0がリリースされましたを参照してください。
modules
というディレクトリがあります。これは環境毎に分けたくないリソースを置くためのものです。例えば、IAMやRoute53のHosted Zoneなど特定の環境のみで管理したい場合もあると思います。VPC外で管理されるかつリージョンの概念がないためです(Private Hosted Zoneを除いて)。別のディレクトリに切り出すことで特定の環境からのみモジュールを呼び出すことが可能です。例えば本番環境からのみ呼び出す場合は以下のようになります。
provider "aws" { region = "${var.region}" } module "iam" { source = "../../modules/iam" name = "${var.name}" } module "prd" { source = "../../" name = "${var.name}" vpc_cidr = "${var.vpc_cidr}" email_address = "${var.email_address}" instance_types = "${var.instance_types}" asg_config = "${var.asg_config}" db_config = "${var.db_config}" cf_config = "${var.cf_config}" elasticache_config = "${var.elasticache_config}" azs = "${data.aws_availability_zones.az.names}" amazon_linux_id = "${data.aws_ami.amazon_linux.id}" instance_profile_id = "${module.iam.instance_profile_id}" domain_config = "${var.domain_config}" } module "dns" { source = "../../modules/dns" domain_config = "${var.domain_config}" bastion_public_ip = "${module.prd.bastion_public_ip}" elb_config = "${module.prd.elb_config}" cf_config = "${module.prd.cf_config}" }
もちろんこういった用途がない場合はこのディレクトリは不要です。
ちなみにですが、v0.7.0から map
と merge
という関数が取り込まれました。この関数を利用すると、 outputs.tf
でマップを出力可能です。
output "elb_config" { value = "${merge(map("dns_name", "${aws_elb.elb.dns_name}"), map("zone_id", "${aws_elb.elb.zone_id}"))}" }
4. シンプルさを保っている
私がこの構成を気に入っている理由の1つにそのシンプルさがあります。基本的にトップディレクトリにtfファイルを置いていけばいいからです。コンピューティング系リソース、ネットワーク系リソースなどコンポーネント単位でさらにディレクトリを切り、分類するといったことも可能なのですが(例えばhashicorp/best-practices)、モジュールのところで解説したように、変数の定義が煩雑になるためあまりオススメはしません。それよりは、トップディレクトリにずらずらっとtfファイルを並べる構成にする方がシンプルかつ変数定義地獄から逃れられると考えています。
まとめ
いかがでしょうか。
環境によっては別の構成のほうがよい場合もあるかと思いますが、大方この構成でよいのではと考えています。別の方式をご存知でしたらコメント欄で教えていただければと思います。
本エントリがみなさんの参考になれば幸いです。